<?php
namespace Tlf;
/**
* Extend this class to represent a single row from your database. Provides many convenience features like saving, hooks, and coercing values between the database version & application version
*
*/
class BigOrm {
/**
* Used to track types of properties for database coersion purposes
* @override to reduce overhead from using Reflection to determine property type.
* array<string property_name, string type_from_reflection> where type_from_reflection
*/
static protected $__reflection_types__ = [];
public string $table;
protected BigDb $db;
/**
* Create a BigOrm instance.
* @param $db a BigDb instance
*/
public function __construct(BigDb $db){
$this->db = $db;
}
/**
* Get a db-representation of your item. This is intended to convert your Orm object into a mysql-storeable array. It is NOT intended to load from the database.
*
* @return array<string, mixed>
*
* @override required because there is no default implementation
*/
public function get_db_row(): array {
throw new \RuntimeException("You must override `get_db_row():array` in your ORM class '".get_class($this)."'");
return [];
}
/**
* Set each key/value pair to the same-named properties on this class
*
* @param $db_row array<string key, mixed value> raw as retrieved from database
* @param $props_to_coerce array<int index, string key> properties to run through automatic-conversion based mostly on declared property type.
*
* @return void
*/
protected function row_to_props(array $db_row, ...$props_to_coerce): void {
//// previous version used a single loop over db_row
//// Which meant every key faced an in_array() check + branching
//// This version saves that step
foreach ($props_to_coerce as $key){
$this->$key = $this->get_prop_value($key, $db_row[$key]);
unset($db_row[$key]);
}
foreach ($db_row as $key=>$value){
$this->$key = $value;
}
}
/**
* Get a database-friendly row from an array of property values
*
* @param $raw_props array<int index, string key> property names that require no conversion
* @param $coerce_props array<int index, string key> property names that require conversion
*
* @return void
*/
protected function props_to_row(array $raw_props, array $coerce_props): array {
$row = [];
foreach ($raw_props as $index=>$prop_name){
$row[$prop_name] = isset($this->$prop_name) ? $this->$prop_name : null;
}
foreach ($coerce_props as $index=>$prop_name){
$row[$prop_name] = $this->get_db_value($prop_name);
}
return $row;
}
/**
* Get the database value from a property
*
* @param $prop_name string the name of the property on this object
* @param $db_value mixed the value retrieved from the database
*
* @return mixed value to set on the property
* @throw \Exception of the property cannot be coerced.
*/
public function get_prop_value(string $prop_name, mixed $db_value): mixed{
if (isset(static::$__reflection_types__[$prop_name]))$type = static::$__reflection_types__[$prop_name];
else $type = static::$__reflection_types__[$prop_name] = (string)(new \ReflectionProperty($this, $prop_name))->getType();
return $this->db->coerce_from_db($type, $db_value,$prop_name);
}
/**
* Get the database value from a property.
*
* @param $prop_name string the name of the property on this object
*
* @return mixed value to store in the database
* @throw \Exception if property value cannot be coerced to db-friendly value.
*/
public function get_db_value(string $prop_name): mixed{
if (isset(static::$__reflection_types__[$prop_name]))$type = static::$__reflection_types__[$prop_name];
else $type = static::$__reflection_types__[$prop_name] = (string)(new \ReflectionProperty($this, $prop_name))->getType();
$property_value = $this->$prop_name;
return $this->db->coerce_to_db($type, $property_value, $prop_name);
}
/**
* Initialize the Orm object from a database row
*
* @param $row array<string, mixed> a row as it would be retrieved from the database, with @key being the column name & @value being the row's value for that column.
* @return void
*
* @override required because there is no default implementation
*/
public function set_from_db(array $row){
throw new \RuntimeException("You must override `set_from_db(array \$row)` in your ORM class '".get_class($this)."'");
}
/**
* Get an array of this object's properties.
*
* Your subclass may call this from `get_db_row()`
*
* @param ...$properties a list of properties to put into an an array with the same name
* @return array filled with `'prop_name'=>$this->prop_name`
*/
public function props_to_array(...$properties): array {
$row = [];
foreach ($properties as $p){
if (!isset($this->$p))continue;
$row[$p] = $this->$p;
}
return $row;
}
/**
* Set properties from an array. Your subclass may call this from `set_from_db()`
*
* @param $row the array to set properties from
* @param ...$properties a list of property names that have the same key in $row.
* @return void
*/
public function set_props_from_array(array $row, ...$properties): void{
foreach ($properties as $p){
$this->$p = $row[$p];
}
}
/**
* Convert binary uuid to a string uuid (mysql compatible).
* @param $uuid a binary(16) uuid from MYSQL created via `UUID_TO_BIN( UUID() )`
* @return string a VARCHAR(36) compatible $uuid identical to `BIN_TO_UUID( binary_16_representation_of_uuid )`
* @deprecate Use BigDb method instead
*/
public function bin_to_uuid(string $uuid): string{
return $this->db->bin_to_uuid($uuid);
}
/**
* Convert a string uuid to a binary uuid (mysql compatible).
* @param $uuid a VARCHAR(36) representation of a UUID, generated in MySql with `UUID()`
* @return string a BINARY(16) representation of a UUID, generated in MySql with `BIN_TO_UUID( UUID() )`
* @deprecate Use BigDb method instead
*/
public function uuid_to_bin(string $uuid): string{
return $this->db->uuid_to_bin($uuid);
}
/**
* Generate a (hopefully) mysql-compatible UUID string. Method not thoroughly tested for mysql compatability.
*
* @return string `VARCHAR(36)` compatible uuid, compatible with MySql's `UUID()` function
*/
public function generate_uuid(): string {
// copy+pasted from Symfony's uuid polyfill at https://github.com/symfony/polyfill-uuid/blob/1.x/Uuid.php#L320 from the uid_generate_random() method
// uuid_generate_time() may be better to copy+paste, but idk.
$uuid = bin2hex(random_bytes(16));
return sprintf('%08s-%04s-4%03s-%04x-%012s',
// 32 bits for "time_low"
substr($uuid, 0, 8),
// 16 bits for "time_mid"
substr($uuid, 8, 4),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 4
substr($uuid, 13, 3),
// 16 bits:
// * 8 bits for "clk_seq_hi_res",
// * 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
hexdec(substr($uuid, 16, 4)) & 0x3FFF | 0x8000,
// 48 bits for "node"
substr($uuid, 20, 12)
);
}
/**
* Convert a mysql-stored datetime string to a PHP DateTime instance
* @param $mysql_datetime mysql-stored datetime string
* @return DateTime instance
*/
public function str_to_datetime(string $mysql_datetime): \DateTime {
return \DateTime::createFromFormat('Y-m-d H:i:s', $mysql_datetime);
}
/**
* Convert a PHP DateTime object into a mysql DATETIME string
* @param $datetime a DateTime object
* @return a string compatible with MySql's DATETIME type
*/
public function datetime_to_str(\DateTime $datetime): string {
return $datetime->format('Y-m-d H:i:s');
}
/**
* Call and return the property getter. For `$prop = 'author'`, call `$this->getAuthor()`
*
* @param $prop a property name
* @return the value from the property getter.
*/
public function __get(string $prop): mixed {
$method = 'get'.ucfirst($prop);
return $this->$method();
}
/**
* Call the property setter. For `$prop = 'author'`, call `$this->setAuthor($value)`
*
* @param $prop a property name
* @param $value the value to set
* @return void
*/
public function __set(string $prop, mixed $value){
$method = 'set'.ucfirst($prop);
$this->$method($value);
}
/**
* Store the item in the database. If `is_saved()` returns `true`, then use an UPDATE, else use an INSERT.
* UPDATEs are performed based on the `int $id` property of the Orm object, assuming an `id int PRIMARY KEY AUTO_INCREMENT` db column.
*
* @override if your table does not use a primary key, autoincrement `id`, or if your auto increment column has a different name.
* @return int id of the item's db row
*/
public function save(): int {
$row = $this->get_db_row();
$row = $this->onWillSave($row);
if ($this->is_saved()){
$this->db->update($this->table(), ['id'=>$this->id], $row);
} else {
$this->id = $this->db->insert($this->table(), $row);
}
$this->onDidSave($row);
return $this->id;
}
/**
* Delete this item from the database, where the db column `id` matches this item's property `id`
*
* @override if db column `id` is not your unique primary key OR if `$this->id` does not correspond to database column `id`.
* @return `true` if the item was deleted, `false` otherwise. `false` if there is an error or if this item is not already saved in the db.
*/
public function delete(): bool {
if ($this->is_saved()){
$db_row = $this->get_db_row();
if (!$this->onWillDelete($db_row))return false;
$did_delete = $this->db->delete($this->table(), ['id'=>$this->id]);
if ($did_delete){
unset($this->id);
$this->onDidDelete($db_row);
return true;
}
else return false;
} else {
return false;
}
}
/**
* Refreshes this item, so it matches what's in the database. Just queries for this item's row (by id), then calls `$this->set_from_db($row)`.
*
* @throw RuntimeException if `$this->id` is not set, or if no rows are returned, or if more than one row is returned.
* @return the old db row, as gotten from `$this->get_db_row()`
*
* @override to refresh based on a property/column other than `id`, or if you want different error handling than exceptions.
*/
public function refresh(): array {
$old_row = $this->get_db_row();
if (!isset($this->id)){
throw new \RuntimeException("Cannot refresh. This item's `id` is not set, so it cannot be refreshed. Class '".get_class($this)."' can override `refresh()` if `id` is not the reference property.");
}
$rows = $this->db->select($this->table(), ['id'=>$this->id]);
if (count($rows)==0){
throw new \RuntimeException("Cannot refresh. Could not find a row with id '".$this->id."' in table '".$this->table()."'");
}
if (count($rows)>1){
throw new \RuntimeException("Cannot refresh. Multiple rows returned with id '".$this->id."' in table '".$this->table()."'");
}
$this->set_from_db($rows[0]);
return $old_row;
}
/**
* Check if the current item is already stored in the database. Default implementation returns true if `id` property isset & is > 0
*
* @override if the `id` property/column is not reliable for determining whether your item already exists in the database.
* @return true if the item is already in the database, false otherwse
*/
public function is_saved(): bool{
return isset($this->id) && $this->id > 0;
}
/**
* Get the table name. Default implementation return `$this->table` or the lowercase version of the class name if `$this->table` is null
*
* @override if you are not setting the `table` property AND your class's basename does not map to the table's name in the database.
* @return string database table name
*/
public function table(): string {
if (isset($this->table))return $this->table;
$parts = explode('\\', strtolower(get_class($this)));
$class = array_pop($parts);
return $class;
}
/**
* Hook called before an item is saved. Returns the correct row to save.
*
* @param $row array<string, mixed> the array returned by `get_db_row()`
* @return array<string, mixed> the correct row to save to database
*
* @override if you need to modify `$row` prior to INSERT/UPDATE, or if you need to do something else prior to db storage.
*/
public function onWillSave(array $row): array {
return $row;
}
/**
* Hook called after an item is saved.
*
* @param $row array<string, mixed> the row that was used for INSERT/UPDATE, typically same as `get_db_row()`, or a modified copy returned by `onWillSave()`
* @return void
*
* @override if you need to query values auto-generated by mysql, or if you need to perform other actions after INSERT/UPDATE
*/
public function onDidSave(array $row) {
}
/**
* Hook called before an item is deleted.
*
* @param $row array<string, mixed> the array returned by `get_db_row()`
* @return `false` to stop deletion or `true` to continue.
*
* @override If there are cases where you want to prevent deletion of an item. Cleanup should go in `onDidDelete(array $row)`, but pre-steps should go here. Ex: Article can only be deleted if its tags are deleted first. Delete tags during onWillDelete & if they fail to delete, then return `false` to prevent article deletion.
*/
public function onWillDelete(array $row): bool {
return true;
}
/**
* Hook called after an item is deleted from database.
*
* @param $row array<string, mixed> the row that was deleted. This is gotten from `$this->get_db_row()` before the deletion, NOT from the database
* @return void
*
* @override if you need to do some cleanup after deletion
*/
public function onDidDelete(array $row) {
}
}